Skip to content

fix(text): don't shape stale content when TextLayout is built outside measure - #21904

Closed
gentledepp wants to merge 1 commit into
AvaloniaUI:mainfrom
gentledepp:fix/21902_textblock-textruncache-blank
Closed

fix(text): don't shape stale content when TextLayout is built outside measure#21904
gentledepp wants to merge 1 commit into
AvaloniaUI:mainfrom
gentledepp:fix/21902_textblock-textruncache-blank

Conversation

@gentledepp

@gentledepp gentledepp commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #21902.

Check out the before- and after video I added to the corresponding issue:
#21902

What's broken

A TextBlock whose content lives in Inlines rather than Text can end up rendering nothing,
permanently. It keeps its height, it's visible, Inlines.Count > 0, Bounds is ample — but
DesiredSize.Width is 0. Re-measuring doesn't fix it, forcing a full layout pass on an ancestor
doesn't fix it, and replacing the whole InlineCollection doesn't reliably fix it either. Setting
FontSize fixes it instantly, which is the clue that cracked it: FontSize is one of the
properties routed through InvalidateTextLayout(), and that is the only code path that calls
_textRunCache?.Invalidate().

So it's the run cache from #21030, and here's the chain:

TextRunCache keys its entries on firstTextSourceIndex alone — there's nothing in an entry that
says which content produced it. And TextFormatterImpl.FormatLine checks the cache before it
fetches anything from the text source, so an entry at index 0 beats whatever the TextBlock
actually holds right now. That's fine as long as nothing ever caches the wrong thing, and normally
nothing does, because every content mutation routes through InvalidateTextLayout().

The hole is that TextBlock can build a layout from the wrong text source in the first place:

  • TextLayout is lazy and public: _textLayout ??= CreateTextLayout(Text).
  • CreateTextLayout uses _textRuns if it has them, and otherwise silently falls back to
    new SimpleTextSource(text ?? "").
  • _textRuns is only ever built in MeasureOverride, and it's set back to null in
    OnMeasureInvalidated.

Put those together and there's a window between a measure invalidation and the next measure pass
where _textRuns is null while Inlines is non-empty. If the content lives entirely in
Inlines, then Text is null, so the fallback happily shapes the empty string — and that empty
result gets written into the cache at key 0. The fetchedRuns.Count == 0 early-out doesn't catch
it, because FetchTextRuns adds the TextEndOfParagraph run before it breaks, so the count is 1,
not 0. From then on every measure and every arrange hits the poisoned entry and gets an empty line
back, forever.

Reading TextLayout in that window is not exotic — it's a public property and RenderTextLayout
reads it, so a render pass that runs before a queued measure is enough. The realistic trigger is
any binding that assigns Inlines asynchronously (converter yields an empty InlineCollection
until the VM delivers the real value a dispatcher turn later).

The fix

Build the runs on demand in the TextLayout getter instead of letting it fall back to Text:

public TextLayout TextLayout => _textLayout ??= CreateTextLayoutCore();

CreateTextLayoutCore builds _textRuns from Inlines if they're missing and we have complex
content, then delegates to CreateTextLayout(Text) as before. Net effect: a layout created outside
a measure pass can no longer shape the wrong content, so it can no longer cache the wrong content.

Two notes on the shape of it:

  • It's in the getter, not in CreateTextLayout. That's deliberate — AccessText and
    SelectableTextBlock both override CreateTextLayout and inherit the same fallback, and putting
    the guard in the getter covers them too without touching either class.
  • It's guarded by HasComplexContent, so the pure-Text path is completely unaffected and there's
    no extra work in the common case.

Why not one of the other options

I tried a few things first, in case any of these is the reviewer's first instinct:

  • Invalidate _textRunCache in OnMeasureInvalidated, where _textRuns gets nulled. Doesn't
    work on its own: the poisoning read happens after that point, so the cache just gets re-poisoned
    before the next measure. It would also throw away the measure→arrange reuse that Implement TextRunCache #21030 exists to
    provide.
  • Reset _textLayout in MeasureOverride whenever _textRuns is rebuilt. Fixes the stale
    layout but not the cache. Test 3 below still fails, because the poisoned entry outlives the
    layout being dropped.
  • Give TextRunCache entries a content identity — e.g. a generation counter bumped whenever the
    text source changes. Honestly I think this is the right long-term shape, and it would close the
    class of bug rather than this one instance. But it's a much bigger change and touches the caching
    contract, so I went with the narrow fix that closes the reachable hole in TextBlock. Happy to
    take a run at the bigger version instead if you'd prefer that.

Tests

Three tests added to TextBlockTests. On main without the fix, tests 2 and 3 fail with
DesiredSize was 0, 15; all three pass with it.

The third one (..._Across_Constraints) is the one that actually pins the diagnosis: it measures at
a different constraint, which forces MeasureOverride to drop _textLayout. It still fails
without the fix, so the problem can't be explained by layout caching — it's the run cache.

Full suites on main with the patch, no regressions:

Suite Result
Avalonia.Controls.UnitTests 3692 total, 3691 passed, 0 failed, 1 skipped
Avalonia.Base.UnitTests 3020 total, 3008 passed, 0 failed, 12 skipped

One thing I noticed but did not fix

CachedShapingResult also holds non-shaped runs like EmbeddedControlRun from InlineUIContainer.
Those get measured against whatever constraint was in force when Inline.BuildTextRun ran, and
MeasureOverride rebuilding _textRuns at a new constraint doesn't invalidate the cache. That
looks like a second, independent staleness path for InlineUIContainer sizing. It predates this
change and I didn't dig into it — flagging it in case it's news.

Draft because I'd like a maintainer's read on the narrow-vs-generation-counter question before this
is considered final.

@Gillibald since you are the master of text caching and this seems to be a regression, can you please have a short look and let me know if this at all makess sense? It does fix my rendering issue. But I am not sure if I break something with it

@gentledepp
gentledepp force-pushed the fix/21902_textblock-textruncache-blank branch from 76da4cb to 587fa70 Compare July 31, 2026 09:47
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0067992-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@grokys

grokys commented Aug 24, 2026

Copy link
Copy Markdown
Member

@gentledepp what's the status of this PR? Is it ready for review?

@yself

yself commented Sep 3, 2026

Copy link
Copy Markdown

I am running into the symptoms as described on Avalonia 12.1.2 and prior 12.* versions. TextBlock with inlines sometimes ends up with Width=0 / empty, after IsVisible is toggled on one of the parents. Invalidating measure/arrange does not help, only touching the Inlines, FontSize, ... helps. Will this get merged? Thanks!

@grokys

grokys commented Sep 4, 2026

Copy link
Copy Markdown
Member

@yself - we're waiting for the PR author to respond; the PR is still marked as draft. Maybe @Gillibald may have some input though.

@MrJul

MrJul commented Sep 7, 2026

Copy link
Copy Markdown
Member

Closing in favor of #22149

@MrJul MrJul closed this Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TextBlock with Inlines renders permanently blank after TextRunCache is poisoned by an out-of-measure TextLayout read

6 participants